package edu.hawaii.halealohacli.jcev.command;

import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.datatype.XMLGregorianCalendar;
import org.wattdepot.client.WattDepotClient;
import org.wattdepot.resource.sensordata.jaxb.SensorData;
import org.wattdepot.resource.source.jaxb.Source;
import org.wattdepot.util.tstamp.Tstamp;

/**
 * Returns the energy used for the specified tower from the specified date to now. Dates must be in
 * (yyyy-MM-dd) format. Syntax: energy-since [tower | lounge] [date].
 * 
 * @author Jordan Takayama
 */
public class EnergySince implements Command {
  /**
   * The WattDepot client that is used to request data from the server.
   */
  private WattDepotClient client;
  /**
   * The name of the Source for which data is being requested.
   */
  private String name;
  /**
   * The SensorData associated with the Source.
   */
  private SensorData data;
  /**
   * The Timestamp that represents the start time for the energy consumption data request.
   */
  private XMLGregorianCalendar startTimestamp;
  /**
   * The Timestamp that represents the end time for the energy consumption data request.
   */
  private XMLGregorianCalendar endTimestamp;
  /**
   * The total energy used by the source. Values are assumed to be in kilowatt-hours.
   */
  private Double energyUsed;

  /**
   * Determines the formatting for energyUsed.
   */
  private static final DecimalFormat ENERGY_FORMAT = new DecimalFormat("####.##");

  /**
   * Verifies the arguments, executes the command, and prints the results to the screen.
   * 
   * @param args Space delimited string of arguments (i.e. "arg1 arg2 arg3").
   * @param client The WattDepot client that will be performing the queries.
   * @throws Exception If an exception was thrown by the command (i.e. bad arguments) or any of its
   * function calls.
   */
  @Override
  public void execute(String args, WattDepotClient client) throws Exception {
    if (args.length() > 0) {
      String[] splitArgs = args.split("[ ]");
      String sourceName = null;
      if (splitArgs.length < 2) {
        throw new Exception("Bad input: Less than 2 arguments.\n" 
            + "Correct syntax: energy-since [tower | lounge] [date]");
      }
      if (splitArgs.length > 2) {
        throw new Exception("Bad input: More than 2 arguments.\n" 
            + "Correct syntax: energy-since [tower | lounge] [date]");
      }
      else if (splitArgs.length == 2) {
        List<Source> sources = client.getSources();
        for (Source source : sources) {
          if (source.getName().equals(splitArgs[0])) {
            sourceName = source.getName();
          }
        }
        if (sourceName == null) {
          throw new Exception("Bad input: Data for source " + splitArgs[0] + " is not available.");
        }
        else {
          if (isDateFormatCorrect(splitArgs[1])) {
            if (inCalendarRanges(splitArgs[1])) {
              if (notInFuture(splitArgs[1])) {
                EnergySince energyUsed = new EnergySince();
                energyUsed = energyUsed.setAll(splitArgs[0], splitArgs[1], client);
                System.out.println(energyUsed.toString());
              }
              else {
                throw new Exception("Bad input: Date " + splitArgs[1] 
                    + " exceeds the current date.");
              }
            }
            else {
              throw new Exception("Bad input: Date " + splitArgs[1] 
                  + " not within calendar ranges.");
            }
          }
          else {
            throw new Exception("Bad input: Date " + splitArgs[1] + " not in yyyy-mm-dd format.");
          }
        }
      }
    }
    else {
      throw new Exception("Bad input: No arguments given.");
    }
  }

  /**
   * Creates an EnergySince object with all fields uninitialized. An EnergySince object queries a
   * WattDepotClient to retrieve the the total energy consumed by a given tower or lounge from a
   * specified date until now.
   */
  public EnergySince() {
    // Fields should be initialized using setAll.
  }

  /**
   * Initializes all the fields of an EnergySince object.
   * 
   * @param sourceName The name of the Source.
   * @param date The starting date from which energy use will be counted, assumed to be in
   * yyyy-MM-dd format.
   * @param client The WattDepotClient which is being used to collect the data.
   * @return The altered version of this EnergySince object.
   * @throws Exception A generic Exception, or the NotAuthorizedException,
   * ResourceNotFoundException, BadXmlException, or MiscClientException thrown by the
   * WattDepotClient.
   */
  private EnergySince setAll(String sourceName, String date, WattDepotClient client)
      throws Exception {
    this.setClient(client);
    this.setName(sourceName);
    this.setData(client.getLatestSensorData(sourceName));
    this.setStartTimestamp(generateTimestamp(date, "T00:00:00.000", TimeZone.getDefault()));
    this.setEndTimestamp(this.data.getTimestamp());
    // If the interval is greater than zero, this code will erroneously request data
    // past the range of available data if it requests data for the current day within
    // [interval] minutes of midnight, where "interval" is the length of the sampling interval.
    this.setEnergyUsed(client, sourceName, this.getStartTimestamp(), this.getEndTimestamp(), 0);
    return this;
  }

  /**
   * Returns the WattDepot client used by this EnergySince object.
   * 
   * @return The WattDepot client used by this EnergySince object.
   */
  public WattDepotClient getClient() {
    return this.client;
  }

  /**
   * Sets the WattDepot client used by this EnergySince object.
   * 
   * @param client The WattDepot client used by this EnergySince object.
   */
  private void setClient(WattDepotClient client) {
    this.client = client;
  }

  /**
   * Returns the name of the Source for which energy use data is being requested.
   * 
   * @return The name of this EnergySince's Source.
   */
  public String getName() {
    return this.name;
  }

  /**
   * Sets the name of the Source for which energy use data is being requested.
   * 
   * @param name The name of this EnergySince's Source.
   */
  private void setName(String name) {
    this.name = name;
  }

  /**
   * Returns the SensorData associated with the Source.
   * 
   * @return The SensorData associated with the Source.
   */
  public SensorData getData() {
    return this.data;
  }

  /**
   * Sets the SensorData associated with the Source.
   * 
   * @param data The SensorData associated with the Source.
   */
  private void setData(SensorData data) {
    this.data = data;
  }

  /**
   * Returns a copy of the timestamp that represents the start time for the energy consumption data
   * request.
   * 
   * @return The start time used to request energy consumption data.
   */
  public XMLGregorianCalendar getStartTimestamp() {
    return (XMLGregorianCalendar) this.startTimestamp.clone();
  }

  /**
   * Sets the timestamp that represents the start time for the energy consumption data request.
   * 
   * @param startTimestamp The start time used to request energy consumption data.
   */
  private void setStartTimestamp(XMLGregorianCalendar startTimestamp) {
    this.startTimestamp = startTimestamp;
  }

  /**
   * Returns a copy of the timestamp that represents the cutoff time for the energy consumption data
   * request.
   * 
   * @return The end time used to request energy consumption data.
   */
  public XMLGregorianCalendar getEndTimestamp() {
    return (XMLGregorianCalendar) this.endTimestamp.clone();
  }

  /**
   * Sets the Timestamp that represents the cutoff time for the energy consumption data request.
   * 
   * @param endTimestamp The end time used to request energy consumption data.
   */
  private void setEndTimestamp(XMLGregorianCalendar endTimestamp) {
    this.endTimestamp = endTimestamp;
  }

  /**
   * Returns the total energy used by the Source.
   * 
   * @return The total energy used, in kilowatt-hours.
   */
  public Double getEnergyUsed() {
    return this.energyUsed;
  }

  /**
   * Sets the total energy used by the Source by retrieving the total energy consumed in watt-hours
   * from a start time to an end time and converting it into kilowatt-hours.
   * 
   * @param client The WattDepotClient being used to retrieve data.
   * @param sourceName The name of the Source.
   * @param start The starting time for data collection.
   * @param end The ending time for data collection.
   * @param samplingInterval The sampling interval, in minutes.
   * @throws Exception If an exception was thrown by this method or any of its function calls.
   */
  private void setEnergyUsed(WattDepotClient client, String sourceName, XMLGregorianCalendar start,
      XMLGregorianCalendar end, int samplingInterval) throws Exception {
    Double energy = client.getEnergyConsumed(sourceName, start, end, samplingInterval);
    if (energy < 0) {
      throw new Exception("Client returned energy consumption < 0. Counter may have been reset.\n"
          + "See http://code.google.com/p/wattdepot/wiki/PropertyDictionary#Sensor_Data_Properties "
          + "\nfor further information.");
    }
    energy = Double.parseDouble(ENERGY_FORMAT.format(energy / 1000));
    this.energyUsed = energy;
  }

  /**
   * Generates the GMT offset in hours, returned as a string in the form +hh:mm or -hh:mm.
   * 
   * @param zone A time zone.
   * @return The GMT offset of the specified time zone in the form +hh:mm or -hh:mm, where h is
   * hours and m is minutes.
   */
  private String gmtOffsetString(TimeZone zone) {
    String result = "";
    String minutesOffset = ":00";
    // Convert to hours: 1000 milliseconds/second * 60 seconds/minute * 60 minutes/hour = 1 hour.
    int offsetHours = zone.getRawOffset() / (1000 * 60 * 60);
    String offsetString = Integer.toString(offsetHours);
    if (Math.abs(offsetHours) < 10) {
      if (offsetHours < 0) {
        // e.g., "-8" becomes "-08:00"
        result =
            offsetString.substring(0, 1) + "0"
                + offsetString.substring(1, Integer.toString(offsetHours).length()) + minutesOffset;
      }
      else {
        // e.g., "7" becomes "+07:00"
        result = "+0" + offsetString + minutesOffset;
      }
    }
    else {
      if (offsetHours >= 0) {
        // e.g., "10" becomes "+10:00"
        result = "+" + offsetHours + minutesOffset;
      }
      else if (offsetHours < 0) {
        // e.g., "-10" becomes "-10:00"
        result = offsetHours + minutesOffset;
      }
    }
    return result;
  }

  /**
   * Returns an XMLGregorianCalendar with its time set to the parameter of "time".
   * 
   * @param date The date, in format yyyy-mm-dd.
   * @param time The time, in format THH:mm:ss.SSS.
   * @param zone The user's specified time zone.
   * @return An XMLGregorianCalendar representing the desired timestamp.
   * @throws Exception If the date is not in the format yyyy-mm-dd, or if an exception was thrown by
   * this method or any of its function calls.
   */
  private XMLGregorianCalendar generateTimestamp(String date, String time, TimeZone zone)
      throws Exception {
    if (isDateFormatCorrect(date)) {
      return Tstamp.makeTimestamp(date + time + gmtOffsetString(zone));
    }
    else {
      throw new Exception("Incorrectly formatted date. Correct date format: yyyy-mm-dd");
    }
  }

  /**
   * Checks that a date is in the format yyyy-MM-dd.
   * 
   * @param date A string representing a date.
   * @return Whether or not the string is in yyyy-MM-dd format.
   */
  private boolean isDateFormatCorrect(String date) {
    Pattern datePattern = Pattern.compile("^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]+$");
    Matcher matchDate = datePattern.matcher(date);
    return matchDate.matches();
  }

  /**
   * Checks that the month and day of a String representing a date are within calendar ranges.
   * 
   * @param checkedDate The date being checked, expected to be in yyyy-MM-dd format.
   * @return True if the date is within the calendar ranges, false otherwise.
   */
  private boolean inCalendarRanges(String checkedDate) {
    boolean inRange = false;
    int year = Integer.parseInt(checkedDate.substring(0, 4));
    int month = Integer.parseInt(checkedDate.substring(5, 7));
    int day = Integer.parseInt(checkedDate.substring(8, 10));
    if (month > 0 && month <= 12 && day > 0) {
      // Special cases: February and leap years
      if (month == 2) {
        // "The Gregorian leap year rule is: Every year that is exactly divisible by four is a leap
        // year, except for years that are exactly divisible by 100; the centurial years that are
        // exactly divisible by 400 are still leap years."
        // Source: U.S. Naval Observatory, <http://www.usno.navy.mil/USNO/astronomical-applications/
        // astronomical-information-center/calendars/>, retrieved November 13, 2011.
        if (year % 4 == 0 && (!(year % 100 == 0) || year % 400 == 0)) {
          if (day <= 29) {
            inRange = true;
          }
        }
        else {
          if (day <= 28) {
            inRange = true;
          }
        }
      }
      else {
        if ((month == 1 || month == 3 || month == 5 || month == 7 || month == 8 || month == 10 
            || month == 12) && day <= 31) {
          inRange = true;
        }
        else if ((month == 4 || month == 6 || month == 9 || month == 11) && day <= 30) {
          inRange = true;
        }
      }
    }
    return inRange;
  }

  /**
   * Checks that a date does not exceed the current date.
   * 
   * @param date The date to be checked (expected format: yyyy-MM-dd).
   * @return True if the date is acceptable, or false if not.
   */
  private boolean notInFuture(String date) {
    boolean dateVerified = false;
    Date currentDate = new Date();
    SimpleDateFormat rangeChecker = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault());
    String formattedCurrentDate = rangeChecker.format(currentDate);
    int year = Integer.parseInt(formattedCurrentDate.substring(0, 4));
    int month = Integer.parseInt(formattedCurrentDate.substring(5, 7));
    int day = Integer.parseInt(formattedCurrentDate.substring(8, 10));
    int dateYear = Integer.parseInt(date.substring(0, 4));
    int dateMonth = Integer.parseInt(date.substring(5, 7));
    int dateDay = Integer.parseInt(date.substring(8, 10));
    // startYear cannot be greater than endYear, endYear cannot be greater than year.
    if (dateYear <= year) {
      // If the years are the same, check the months.
      if (dateYear == year) {
        // The month must be less than or equal to the current month.
        if (dateMonth < month) {
          dateVerified = true;
        }
        // If the months are equal, the day must be less than or equal to the current day.
        else if (dateMonth == month && dateDay <= day) {
          dateVerified = true;
        }
      }
      // Otherwise the month and day do not matter.
      else {
        dateVerified = true;
      }
    }
    return dateVerified;
  }
  
  /**
   * Adds zeroes to the front of a string representing an integer. If the integer is negative,
   * the zeroes will be added after the '-' sign, but before the first digit.
   * 
   * @param target        The string which is having preceding zeroes added to it.
   * @param desiredLength How long the string should be once preceding zeroes are added.
   * @return              The string with zeroes added.
   * @throws Exception    If the string contains characters other than digits and '-',
   *                      or desiredLength is less than the length of the string
   *                      (or, for negative numbers, less than the length of the string minus one).
   */
  private String addPrecedingZeroes(String target, int desiredLength) throws Exception {
    StringBuilder result = new StringBuilder();
    String compareLength;
    boolean isNegative;
    for (int i = 0; i < target.length(); i++) {
      if (i == 0) {
        if (!(Character.isDigit(target.charAt(i))) && !(target.charAt(i) == '-')) {
          throw new Exception("String " + target + " must contain only digits or a \"-\" character "
              + "at position 0.\n" + "This program only handles strings which represent integers.");
        }
      }
      else if (!Character.isDigit(target.charAt(i))) {
        throw new Exception("String " + target + " must contain only digits or a \"-\" character "
            + "at position 0.\n" + "This program only handles strings which represent integers.");
      }
    }
    if (target.charAt(0) == '-') {
      isNegative = true;
      // Remove the '-' sign before adding zeroes.
      compareLength = target.substring(1, target.length());
    }
    else {
      isNegative = false;
      compareLength = target;
    }
    if (desiredLength < compareLength.length()) {
      throw new Exception("New length \"" + desiredLength + "\" must be greater than or equal to\n" 
          + "original length " + compareLength.length() + " of string \"" + target + "\".");
    }
    
    if (compareLength.length() < desiredLength) {
      // Append the '-' sign to the front of the string.
      if (isNegative) {
        result.append('-');
      }
      for (int i = 0; i < (desiredLength - compareLength.length()); i++) {
        result.append('0');
      }
    }
    result.append(compareLength);
    return result.toString();
  }

  /**
   * Returns the contents of an EnergySince object as a string.
   * 
   * @return The contents of an EnergySince object as a string, or 
   *         "ERROR" if the string conversion failed.
   */
  @Override
  public String toString() {
    StringBuilder output = new StringBuilder();
    StringBuilder startStringBuilder = new StringBuilder();
    StringBuilder endStringBuilder = new StringBuilder();
    Double energyOutput = this.getEnergyUsed();
    // Format output as a cleaner value
    String energyOutputString = ENERGY_FORMAT.format(energyOutput);
    XMLGregorianCalendar start = this.getStartTimestamp();
    XMLGregorianCalendar end = this.getEndTimestamp();
    
    String result = "";
    // The toString method must quietly absorb exceptions in order to override toString.
    // Exceptions will never be thrown during normal operation. addPrecedingZeroes only throws
    // exceptions (1) if a string contains characters other than digits or a '-' at position 0,
    // or (2) if the desired length of a string is less than its current length, minus any
    // '-' characters. Logically, standard months, days, hours, minutes, or seconds values, 
    // which are always one- or two-digit positive integers, will never cause the below code 
    // to throw an exception.
    try {
      // Format to yyyy-MM-dd
      startStringBuilder.append(start.getYear() + "-");
      startStringBuilder.append(addPrecedingZeroes(Integer.toString(start.getMonth()), 2) + "-");
      startStringBuilder.append(addPrecedingZeroes(Integer.toString(start.getDay()), 2) + " ");
      startStringBuilder.append(addPrecedingZeroes(Integer.toString(start.getHour()), 2) + ":");
      startStringBuilder.append(addPrecedingZeroes(Integer.toString(start.getMinute()), 2) + ":");
      startStringBuilder.append(addPrecedingZeroes(Integer.toString(start.getSecond()), 2));
      
      // Format to yyyy-MM-dd
      endStringBuilder.append(end.getYear() + "-");
      endStringBuilder.append(addPrecedingZeroes(Integer.toString(end.getMonth()), 2) + "-");
      endStringBuilder.append(addPrecedingZeroes(Integer.toString(end.getDay()), 2) + " ");
      endStringBuilder.append(addPrecedingZeroes(Integer.toString(end.getHour()), 2) + ":");
      endStringBuilder.append(addPrecedingZeroes(Integer.toString(end.getMinute()), 2) + ":");
      endStringBuilder.append(addPrecedingZeroes(Integer.toString(end.getSecond()), 2));
      
      output.append(this.getName() + "'s total energy consumption from\n"
          + startStringBuilder.toString() + " to " + endStringBuilder.toString() + " is: " 
          + energyOutputString + " kWh\n");
      result = output.toString();
    }
    catch (Exception e) {
      System.out.println("The EnergySince object could not be converted to a string. Reason:\n" 
          + e.getMessage() + "\nThe operation will return \"ERROR\".");
      result = "ERROR";
    }
    return result;
  }

  /**
   * Returns the string to be printed by the help command. Format:
   * "command-name [args1] [args2] .... \nDescription"
   * 
   * @return The string to be printed by the help command.
   */
  @Override
  public String getHelpString() {
    return "energy-since [tower | lounge] [date]\n"
        + "Returns energy in kWh used by the tower or lounge since the date (yyyy-mm-dd) to now.";
  }
}